diff --git a/Makefile b/Makefile index 5b9157e..012be40 100644 --- a/Makefile +++ b/Makefile @@ -1,158 +1,182 @@ GITREV=`git describe | cut -c 2-` LDFLAGS=-ldflags="-s -w -X 'github.com/writefreely/writefreely.softwareVer=$(GITREV)' -extldflags '-static'" BASELDFLAGS=-ldflags="-s -w -X 'github.com/writefreely/writefreely.softwareVer=$(GITREV)'" GOCMD=go GOINSTALL=$(GOCMD) install $(LDFLAGS) GOBUILD=$(GOCMD) build $(LDFLAGS) GOTEST=$(GOCMD) test $(LDFLAGS) GOGET=$(GOCMD) get BINARY_NAME=writefreely BUILDPATH=build/$(BINARY_NAME) DOCKERCMD=docker IMAGE_NAME=writeas/writefreely TMPBIN=./tmp +# Build tags used for normal (disk-based) builds. static/, templates/, and +# pages/ -- except file-based overrides like static/local/custom.css -- are +# read from disk at runtime, so `make ui` / editing those dirs is picked up +# without a rebuild. +TAGS=netgo sqlite +NOSQLITE_TAGS=netgo + +# Adding the `embed` tag compiles the contents of static/, templates/, and +# pages/ into the binary. File-based overrides are still read from disk and +# take precedence over their embedded counterparts. This is what release +# builds use, so they produce a single self-contained binary. +EMBED_TAGS=$(TAGS) embed + all : build ci: deps cd cmd/writefreely; $(GOBUILD) -v build: deps - cd cmd/writefreely; $(GOBUILD) -v -tags='netgo sqlite' + cd cmd/writefreely; $(GOBUILD) -v -tags='$(TAGS)' + +# build-embed produces a binary with static/templates/pages embedded, for +# locally testing embedded release builds without running the full `release` +# target. +build-embed: deps + cd cmd/writefreely; $(GOBUILD) -v -tags='$(EMBED_TAGS)' build-no-sqlite: deps-no-sqlite - cd cmd/writefreely; $(GOBUILD) -v -tags='netgo' -o $(BINARY_NAME) + cd cmd/writefreely; $(GOBUILD) -v -tags='$(NOSQLITE_TAGS)' -o $(BINARY_NAME) build-linux: deps @hash xgo > /dev/null 2>&1; if [ $$? -ne 0 ]; then \ $(GOCMD) install src.techknowlogick.com/xgo@latest; \ fi - xgo --targets=linux/amd64, -dest build/ $(LDFLAGS) -tags='netgo sqlite' -go go-1.25.x -out writefreely -pkg ./cmd/writefreely . + xgo --targets=linux/amd64, -dest build/ $(LDFLAGS) -tags='$(TAGS)' -go go-1.25.x -out writefreely -pkg ./cmd/writefreely . build-windows: deps @hash xgo > /dev/null 2>&1; if [ $$? -ne 0 ]; then \ $(GOCMD) install src.techknowlogick.com/xgo@latest; \ fi - xgo --targets=windows/amd64, -dest build/ $(LDFLAGS) -tags='netgo sqlite' -go go-1.25.x -out writefreely -pkg ./cmd/writefreely . + xgo --targets=windows/amd64, -dest build/ $(LDFLAGS) -tags='$(TAGS)' -go go-1.25.x -out writefreely -pkg ./cmd/writefreely . build-darwin: deps @hash xgo > /dev/null 2>&1; if [ $$? -ne 0 ]; then \ $(GOCMD) install src.techknowlogick.com/xgo@latest; \ fi - xgo --targets=darwin/amd64, -dest build/ $(BASELDFLAGS) -tags='netgo sqlite' -go go-1.25.x -out writefreely -pkg ./cmd/writefreely . + xgo --targets=darwin/amd64, -dest build/ $(BASELDFLAGS) -tags='$(TAGS)' -go go-1.25.x -out writefreely -pkg ./cmd/writefreely . build-darwin-arm64: deps @hash xgo > /dev/null 2>&1; if [ $$? -ne 0 ]; then \ $(GOCMD) install src.techknowlogick.com/xgo@latest; \ fi - xgo --targets=darwin/arm64, -dest build/ $(BASELDFLAGS) -tags='netgo sqlite' -go go-1.25.x -out writefreely -pkg ./cmd/writefreely . + xgo --targets=darwin/arm64, -dest build/ $(BASELDFLAGS) -tags='$(TAGS)' -go go-1.25.x -out writefreely -pkg ./cmd/writefreely . build-arm6: deps @hash xgo > /dev/null 2>&1; if [ $$? -ne 0 ]; then \ $(GOCMD) install src.techknowlogick.com/xgo@latest; \ fi - xgo --targets=linux/arm-6, -dest build/ $(LDFLAGS) -tags='netgo sqlite' -go go-1.25.x -out writefreely -pkg ./cmd/writefreely . + xgo --targets=linux/arm-6, -dest build/ $(LDFLAGS) -tags='$(TAGS)' -go go-1.25.x -out writefreely -pkg ./cmd/writefreely . build-arm7: deps @hash xgo > /dev/null 2>&1; if [ $$? -ne 0 ]; then \ $(GOCMD) install src.techknowlogick.com/xgo@latest; \ fi - xgo --targets=linux/arm-7, -dest build/ $(LDFLAGS) -tags='netgo sqlite' -go go-1.25.x -out writefreely -pkg ./cmd/writefreely . + xgo --targets=linux/arm-7, -dest build/ $(LDFLAGS) -tags='$(TAGS)' -go go-1.25.x -out writefreely -pkg ./cmd/writefreely . build-arm64: deps @hash xgo > /dev/null 2>&1; if [ $$? -ne 0 ]; then \ $(GOCMD) install src.techknowlogick.com/xgo@latest; \ fi - xgo --targets=linux/arm64, -dest build/ $(LDFLAGS) -tags='netgo sqlite' -go go-1.25.x -out writefreely -pkg ./cmd/writefreely . + xgo --targets=linux/arm64, -dest build/ $(LDFLAGS) -tags='$(TAGS)' -go go-1.25.x -out writefreely -pkg ./cmd/writefreely . build-docker : $(DOCKERCMD) build -t $(IMAGE_NAME):latest -t $(IMAGE_NAME):$(GITREV) . test: $(GOTEST) -v ./... run: - $(GOINSTALL) -tags='netgo sqlite' ./... + $(GOINSTALL) -tags='$(TAGS)' ./... $(BINARY_NAME) --debug deps : $(GOGET) -tags='sqlite' -d -v ./... deps-no-sqlite: $(GOGET) -d -v ./... install : build cmd/writefreely/$(BINARY_NAME) --config cmd/writefreely/$(BINARY_NAME) --gen-keys cmd/writefreely/$(BINARY_NAME) --init-db cd less/; $(MAKE) install $(MFLAGS) +# static/, templates/, and pages/ are compiled into each release binary via +# the `embed` build tag (see EMBED_TAGS above), so none of them are shipped +# as loose files alongside the binary anymore. File-based overrides like +# static/local/custom.css still work: an admin can create the relevant +# directory next to the binary and it takes precedence over the embedded +# copy. +# +# Because `embed` reads templates/ from source rather than from $(BUILDPATH), +# the CSS cache-busting rewrite has to run against the tracked templates/ +# directory itself instead of a disposable copy. It's reverted via +# `git checkout` once every platform binary has been built. release : clean ui mkdir -p $(BUILDPATH) - rsync -av --exclude=".*" templates $(BUILDPATH) - rsync -av --exclude=".*" pages $(BUILDPATH) - rsync -av --exclude=".*" static $(BUILDPATH) - rm -r $(BUILDPATH)/static/local - scripts/invalidate-css.sh $(BUILDPATH) mkdir $(BUILDPATH)/keys - $(MAKE) build-linux + scripts/invalidate-css.sh . + $(MAKE) build-linux TAGS='$(EMBED_TAGS)' mv build/$(BINARY_NAME)-linux-amd64 $(BUILDPATH)/$(BINARY_NAME) tar -cvzf $(BINARY_NAME)_$(GITREV)_linux_amd64.tar.gz -C build $(BINARY_NAME) rm $(BUILDPATH)/$(BINARY_NAME) - $(MAKE) build-arm6 + $(MAKE) build-arm6 TAGS='$(EMBED_TAGS)' mv build/$(BINARY_NAME)-linux-arm-6 $(BUILDPATH)/$(BINARY_NAME) tar -cvzf $(BINARY_NAME)_$(GITREV)_linux_arm6.tar.gz -C build $(BINARY_NAME) rm $(BUILDPATH)/$(BINARY_NAME) - $(MAKE) build-arm7 + $(MAKE) build-arm7 TAGS='$(EMBED_TAGS)' mv build/$(BINARY_NAME)-linux-arm-7 $(BUILDPATH)/$(BINARY_NAME) tar -cvzf $(BINARY_NAME)_$(GITREV)_linux_arm7.tar.gz -C build $(BINARY_NAME) rm $(BUILDPATH)/$(BINARY_NAME) - $(MAKE) build-arm64 + $(MAKE) build-arm64 TAGS='$(EMBED_TAGS)' mv build/$(BINARY_NAME)-linux-arm64 $(BUILDPATH)/$(BINARY_NAME) tar -cvzf $(BINARY_NAME)_$(GITREV)_linux_arm64.tar.gz -C build $(BINARY_NAME) rm $(BUILDPATH)/$(BINARY_NAME) - $(MAKE) build-darwin + $(MAKE) build-darwin TAGS='$(EMBED_TAGS)' mv build/$(BINARY_NAME)-darwin-10.12-amd64 $(BUILDPATH)/$(BINARY_NAME) tar -cvzf $(BINARY_NAME)_$(GITREV)_macos_amd64.tar.gz -C build $(BINARY_NAME) rm $(BUILDPATH)/$(BINARY_NAME) - $(MAKE) build-darwin-arm64 + $(MAKE) build-darwin-arm64 TAGS='$(EMBED_TAGS)' mv build/$(BINARY_NAME)-darwin-10.12-arm64 $(BUILDPATH)/$(BINARY_NAME) tar -cvzf $(BINARY_NAME)_$(GITREV)_macos_arm64.tar.gz -C build $(BINARY_NAME) rm $(BUILDPATH)/$(BINARY_NAME) - $(MAKE) build-windows + $(MAKE) build-windows TAGS='$(EMBED_TAGS)' mv build/$(BINARY_NAME)-windows-4.0-amd64.exe $(BUILDPATH)/$(BINARY_NAME).exe cd build; zip -r ../$(BINARY_NAME)_$(GITREV)_windows_amd64.zip ./$(BINARY_NAME) rm $(BUILDPATH)/$(BINARY_NAME).exe + git checkout -- templates # This assumes you're on linux/amd64 release-linux : clean ui mkdir -p $(BUILDPATH) - cp -r templates $(BUILDPATH) - cp -r pages $(BUILDPATH) - cp -r static $(BUILDPATH) mkdir $(BUILDPATH)/keys - $(MAKE) build-no-sqlite + $(MAKE) build-no-sqlite NOSQLITE_TAGS='netgo embed' mv cmd/writefreely/$(BINARY_NAME) $(BUILDPATH)/$(BINARY_NAME) tar -cvzf $(BINARY_NAME)_$(GITREV)_linux_amd64.tar.gz -C build $(BINARY_NAME) release-docker : $(DOCKERCMD) push $(IMAGE_NAME) ui : force_look cd less/; $(MAKE) $(MFLAGS) cd prose/; $(MAKE) $(MFLAGS) $(TMPBIN): mkdir -p $(TMPBIN) $(TMPBIN)/xgo: deps $(TMPBIN) $(GOBUILD) -o $(TMPBIN)/xgo src.techknowlogick.com/xgo clean : -rm -rf build -rm -rf tmp cd less/; $(MAKE) clean $(MFLAGS) force_look : true diff --git a/author/author.go b/author/author.go index 0c2ea57..229b43c 100644 --- a/author/author.go +++ b/author/author.go @@ -1,137 +1,148 @@ /* * Copyright © 2018-2021 Musing Studio LLC. * * This file is part of WriteFreely. * * WriteFreely is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License, included * in the LICENSE file in this source code package. */ package author import ( + "io/fs" "os" "path/filepath" "regexp" "github.com/writeas/web-core/log" "github.com/writefreely/writefreely/config" ) +// PagesFS, when set, is used to enumerate reserved page names instead of +// reading cfg.Server.PagesParentDir off disk. The main writefreely package +// sets this at startup to its pages filesystem, which may be backed by +// assets embedded in the binary rather than files on disk. +var PagesFS fs.FS + // Regex pattern for valid usernames var validUsernameReg = regexp.MustCompile("^[a-zA-Z0-9][a-zA-Z0-9-]*$") // List of reserved usernames var reservedUsernames = map[string]bool{ "a": true, "about": true, "add": true, "admin": true, "administrator": true, "adminzone": true, "api": true, "article": true, "articles": true, "auth": true, "authenticate": true, "browse": true, "c": true, "categories": true, "category": true, "changes": true, "community": true, "create": true, "css": true, "data": true, "dev": true, "developers": true, "draft": true, "drafts": true, "edit": true, "edits": true, "faq": true, "feed": true, "feedback": true, "guide": true, "guides": true, "help": true, "index": true, "invite": true, "js": true, "login": true, "logout": true, "me": true, "media": true, "meta": true, "metadata": true, "new": true, "news": true, "oauth": true, "post": true, "posts": true, "privacy": true, "publication": true, "publications": true, "publish": true, "random": true, "read": true, "reader": true, "register": true, "remove": true, "signin": true, "signout": true, "signup": true, "start": true, "status": true, "summary": true, "support": true, "tag": true, "tags": true, "team": true, "template": true, "templates": true, "terms": true, "terms-of-service": true, "termsofservice": true, "theme": true, "themes": true, "tips": true, "tos": true, "update": true, "updates": true, "user": true, "users": true, "yourname": true, } // IsValidUsername returns true if a given username is neither reserved nor // of the correct format. func IsValidUsername(cfg *config.Config, username string) bool { // Username has to be above a character limit if len(username) < cfg.App.MinUsernameLen { return false } // Username is invalid if page with the same name exists. So traverse // available pages, adding them to reservedUsernames map that'll be checked // later. - err := filepath.Walk(filepath.Join(cfg.Server.PagesParentDir, "pages"), func(path string, i os.FileInfo, err error) error { + pagesFS := PagesFS + if pagesFS == nil { + pagesFS = os.DirFS(filepath.Join(cfg.Server.PagesParentDir, "pages")) + } + err := fs.WalkDir(pagesFS, ".", func(path string, d fs.DirEntry, err error) error { if err != nil { return err } - reservedUsernames[i.Name()] = true + reservedUsernames[d.Name()] = true return nil }) if err != nil { log.Error("[IMPORTANT WARNING]: Could not determine IsValidUsername! %s", err) return false } // Username is invalid if it is reserved! if _, reserved := reservedUsernames[username]; reserved { return false } // TODO: use correct regexp function here return len(validUsernameReg.FindStringSubmatch(username)) > 0 } diff --git a/routes.go b/routes.go index f8979cd..fc2b9dc 100644 --- a/routes.go +++ b/routes.go @@ -1,265 +1,264 @@ /* * Copyright © 2018-2021 Musing Studio LLC. * * This file is part of WriteFreely. * * WriteFreely is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License, included * in the LICENSE file in this source code package. */ package writefreely import ( "net/http" "net/url" - "path/filepath" "strings" "github.com/gorilla/csrf" "github.com/gorilla/mux" "github.com/writeas/go-webfinger" "github.com/writeas/web-core/log" "github.com/writefreely/go-nodeinfo" ) // InitStaticRoutes adds routes for serving static files. // TODO: this should just be a func, not method func (app *App) InitStaticRoutes(r *mux.Router) { // Handle static files - fs := http.FileServer(http.Dir(filepath.Join(app.cfg.Server.StaticParentDir, staticDir))) + fs := http.FileServer(staticFileSystem(app.cfg.Server.StaticParentDir)) fs = cacheControl(fs) app.shttp = http.NewServeMux() app.shttp.Handle("/", fs) r.PathPrefix("/").Handler(fs) } // InitRoutes adds dynamic routes for the given mux.Router. func InitRoutes(apper Apper, r *mux.Router) *mux.Router { // Create handler handler := NewWFHandler(apper) // Set up routes hostSubroute := apper.App().cfg.App.Host[strings.Index(apper.App().cfg.App.Host, "://")+3:] if apper.App().cfg.App.SingleUser { hostSubroute = "{domain}" } else { if strings.HasPrefix(hostSubroute, "localhost") { hostSubroute = "localhost" } } if apper.App().cfg.App.SingleUser { log.Info("Adding %s routes (single user)...", hostSubroute) } else { log.Info("Adding %s routes (multi-user)...", hostSubroute) } // Primary app routes write := r.PathPrefix("/").Subrouter() // Federation endpoint configurations wf := webfinger.Default(wfResolver{apper.App().db, apper.App().cfg}) wf.NoTLSHandler = nil // Federation endpoints // host-meta write.HandleFunc("/.well-known/host-meta", handler.Web(handleViewHostMeta, UserLevelReader)) // webfinger write.HandleFunc(webfinger.WebFingerPath, handler.LogHandlerFunc(http.HandlerFunc(wf.Webfinger))) // nodeinfo niCfg := nodeInfoConfig(apper.App().db, apper.App().cfg) ni := nodeinfo.NewService(*niCfg, nodeInfoResolver{apper.App().cfg, apper.App().db}) write.HandleFunc(nodeinfo.NodeInfoPath, handler.LogHandlerFunc(http.HandlerFunc(ni.NodeInfoDiscover))) write.HandleFunc(niCfg.InfoURL, handler.LogHandlerFunc(http.HandlerFunc(ni.NodeInfo))) // handle mentions write.HandleFunc("/@/{handle}", handler.Web(handleViewMention, UserLevelReader)) configureSlackOauth(handler, write, apper.App()) configureWriteAsOauth(handler, write, apper.App()) configureGitlabOauth(handler, write, apper.App()) configureGenericOauth(handler, write, apper.App()) configureGiteaOauth(handler, write, apper.App()) // Set up dynamic page handlers // Handle auth auth := write.PathPrefix("/api/auth/").Subrouter() if apper.App().cfg.App.OpenRegistration { auth.HandleFunc("/signup", handler.All(apiSignup)).Methods("POST") } auth.HandleFunc("/login", handler.All(login)).Methods("POST") auth.HandleFunc("/read", handler.WebErrors(handleWebCollectionUnlock, UserLevelNone)).Methods("POST") auth.HandleFunc("/me", handler.All(handleAPILogout)).Methods("DELETE") // Handle logged in user sections me := write.PathPrefix("/me").Subrouter() me.HandleFunc("/", handler.Redirect("/me", UserLevelUser)) me.HandleFunc("/c", handler.Redirect("/me/c/", UserLevelUser)).Methods("GET") me.HandleFunc("/c/", handler.User(viewCollections)).Methods("GET") me.HandleFunc("/c/{collection}", handler.User(viewEditCollection)).Methods("GET") me.HandleFunc("/c/{collection}/stats", handler.User(viewStats)).Methods("GET") me.HandleFunc("/c/{collection}/subscribers", handler.User(handleViewSubscribers)).Methods("GET") me.Path("/delete").Handler(csrf.Protect(apper.App().keys.CSRFKey, csrf.Path("/"))(handler.User(handleUserDelete))).Methods("POST") me.HandleFunc("/posts", handler.Redirect("/me/posts/", UserLevelUser)).Methods("GET") me.HandleFunc("/posts/", handler.User(viewArticles)).Methods("GET") me.HandleFunc("/posts/export.csv", handler.Download(viewExportPosts, UserLevelUser)).Methods("GET") me.HandleFunc("/posts/export.zip", handler.Download(viewExportPosts, UserLevelUser)).Methods("GET") me.HandleFunc("/posts/export.json", handler.Download(viewExportPosts, UserLevelUser)).Methods("GET") me.HandleFunc("/export", handler.User(viewExportOptions)).Methods("GET") me.HandleFunc("/export.json", handler.Download(viewExportFull, UserLevelUser)).Methods("GET") me.Path("/import").Handler(csrf.Protect(apper.App().keys.CSRFKey, csrf.Path("/"))(handler.User(viewImport))).Methods("GET") me.Path("/settings").Handler(csrf.Protect(apper.App().keys.CSRFKey, csrf.Path("/"))(handler.User(viewSettings))).Methods("GET") me.Path("/invites").Handler(csrf.Protect(apper.App().keys.CSRFKey, csrf.Path("/"))(handler.User(handleViewUserInvites))).Methods("GET") me.HandleFunc("/logout", handler.Web(viewLogout, UserLevelNone)).Methods("GET") write.HandleFunc("/api/me", handler.All(viewMeAPI)).Methods("GET") apiMe := write.PathPrefix("/api/me/").Subrouter() apiMe.HandleFunc("/", handler.All(viewMeAPI)).Methods("GET") apiMe.HandleFunc("/posts", handler.UserWebAPI(viewMyPostsAPI)).Methods("GET") apiMe.HandleFunc("/collections", handler.UserAPI(viewMyCollectionsAPI)).Methods("GET") apiMe.HandleFunc("/password", handler.All(updatePassphrase)).Methods("POST") apiMe.Path("/self").Handler(csrfProtectForm(apper.App().keys.CSRFKey, handler.All(updateSettings))).Methods("POST") apiMe.Path("/invites").Handler(csrf.Protect(apper.App().keys.CSRFKey, csrf.Path("/"))(handler.User(handleCreateUserInvite))).Methods("POST") apiMe.Path("/import").Handler(csrf.Protect(apper.App().keys.CSRFKey, csrf.Path("/"))(handler.User(handleImport))).Methods("POST") apiMe.Path("/oauth/remove").Handler(csrf.Protect(apper.App().keys.CSRFKey, csrf.Path("/"))(handler.User(removeOauth))).Methods("POST") // Sign up validation write.HandleFunc("/api/alias", handler.All(handleUsernameCheck)).Methods("POST") write.HandleFunc("/api/markdown", handler.All(handleRenderMarkdown)).Methods("POST") instanceURL, _ := url.Parse(apper.App().Config().App.Host) host := instanceURL.Host // Handle collections write.HandleFunc("/api/collections", handler.All(newCollection)).Methods("POST") apiColls := write.PathPrefix("/api/collections/").Subrouter() apiColls.HandleFunc("/monetization-pointer", handler.PlainTextAPI(handleSPSPEndpoint)).Methods("GET") apiColls.HandleFunc("/"+host, handler.AllReader(fetchCollection)).Methods("GET") apiColls.HandleFunc("/{alias:[0-9a-zA-Z\\-]+}", handler.AllReader(fetchCollection)).Methods("GET") apiColls.HandleFunc("/{alias:[0-9a-zA-Z\\-]+}", handler.All(existingCollection)).Methods("POST", "DELETE") apiColls.HandleFunc("/{alias}/posts", handler.AllReader(fetchCollectionPosts)).Methods("GET") apiColls.HandleFunc("/{alias}/posts", handler.All(newPost)).Methods("POST") apiColls.HandleFunc("/{alias}/posts/{post}", handler.AllReader(fetchPost)).Methods("GET") apiColls.HandleFunc("/{alias}/posts/{post:[a-zA-Z0-9]{10}}", handler.All(existingPost)).Methods("POST") apiColls.HandleFunc("/{alias}/posts/{post}/splitcontent", handler.AllReader(handleGetSplitContent)).Methods("GET", "POST") apiColls.HandleFunc("/{alias}/posts/{post}/{property}", handler.AllReader(fetchPostProperty)).Methods("GET") apiColls.HandleFunc("/{alias}/collect", handler.All(addPost)).Methods("POST") apiColls.HandleFunc("/{alias}/pin", handler.All(pinPost)).Methods("POST") apiColls.HandleFunc("/{alias}/unpin", handler.All(pinPost)).Methods("POST") apiColls.HandleFunc("/{alias}/email/subscribers/export.csv", handler.Download(handleExportEmailSubscriptions, UserLevelUser)).Methods("GET") apiColls.HandleFunc("/{alias}/email/subscribe", handler.All(handleCreateEmailSubscription)).Methods("POST") apiColls.HandleFunc("/{alias}/email/subscribe", handler.All(handleDeleteEmailSubscription)).Methods("DELETE") apiColls.HandleFunc("/{collection}/email/unsubscribe", handler.All(handleDeleteEmailSubscription)).Methods("GET") apiColls.HandleFunc("/{alias}/inbox", handler.All(handleFetchCollectionInbox)).Methods("POST") apiColls.HandleFunc("/{alias}/outbox", handler.AllReader(handleFetchCollectionOutbox)).Methods("GET") apiColls.HandleFunc("/{alias}/following", handler.AllReader(handleFetchCollectionFollowing)).Methods("GET") apiColls.HandleFunc("/{alias}/followers", handler.AllReader(handleFetchCollectionFollowers)).Methods("GET") // Handle posts write.HandleFunc("/api/posts", handler.All(newPost)).Methods("POST") posts := write.PathPrefix("/api/posts/").Subrouter() posts.HandleFunc("/claim", handler.All(addPost)).Methods("POST") posts.HandleFunc("/disperse", handler.All(dispersePost)).Methods("POST") posts.HandleFunc("/{post:[a-zA-Z0-9]+}", handler.AllReader(fetchPost)).Methods("GET") posts.HandleFunc("/{post:[a-zA-Z0-9]+}", handler.All(existingPost)).Methods("POST", "PUT") posts.HandleFunc("/{post:[a-zA-Z0-9]+}", handler.All(deletePost)).Methods("DELETE") posts.HandleFunc("/{post:[a-zA-Z0-9]+}/{property}", handler.AllReader(fetchPostProperty)).Methods("GET") write.HandleFunc("/auth/signup", handler.Web(handleWebSignup, UserLevelNoneRequired)).Methods("POST") write.HandleFunc("/auth/login", handler.Web(webLogin, UserLevelNoneRequired)).Methods("POST") write.HandleFunc("/admin", handler.Admin(handleViewAdminDash)).Methods("GET") write.HandleFunc("/admin/monitor", handler.Admin(handleViewAdminMonitor)).Methods("GET") write.HandleFunc("/admin/settings", handler.Admin(handleViewAdminSettings)).Methods("GET") write.HandleFunc("/admin/users", handler.Admin(handleViewAdminUsers)).Methods("GET") write.HandleFunc("/admin/user/{username}", handler.Admin(handleViewAdminUser)).Methods("GET") write.HandleFunc("/admin/user/{username}/delete", handler.Admin(handleAdminDeleteUser)).Methods("POST") write.HandleFunc("/admin/user/{username}/status", handler.Admin(handleAdminToggleUserStatus)).Methods("POST") write.HandleFunc("/admin/user/{username}/passphrase", handler.Admin(handleAdminResetUserPass)).Methods("POST") write.HandleFunc("/admin/pages", handler.Admin(handleViewAdminPages)).Methods("GET") write.HandleFunc("/admin/page/{slug}", handler.Admin(handleViewAdminPage)).Methods("GET") write.HandleFunc("/admin/update/config", handler.AdminApper(handleAdminUpdateConfig)).Methods("POST") write.HandleFunc("/admin/update/{page}", handler.Admin(handleAdminUpdateSite)).Methods("POST") write.HandleFunc("/admin/updates", handler.Admin(handleViewAdminUpdates)).Methods("GET") // Handle special pages first write.Path("/reset").Handler(csrf.Protect(apper.App().keys.CSRFKey, csrf.Path("/"))(handler.Web(viewResetPassword, UserLevelNoneRequired))) write.HandleFunc("/login", handler.Web(viewLogin, UserLevelNoneRequired)) write.HandleFunc("/signup", handler.Web(handleViewLanding, UserLevelNoneRequired)) write.HandleFunc("/invite/{code:[a-zA-Z0-9]+}", handler.Web(handleViewInvite, UserLevelOptional)).Methods("GET") // TODO: show a reader-specific 404 page if the function is disabled write.HandleFunc("/read", handler.Web(viewLocalTimeline, UserLevelReader)) RouteRead(handler, UserLevelReader, write.PathPrefix("/read").Subrouter()) draftEditPrefix := "" if apper.App().cfg.App.SingleUser { draftEditPrefix = "/d" write.HandleFunc("/me/new", handler.Web(handleViewPad, UserLevelUser)).Methods("GET") } else { write.HandleFunc("/new", handler.Web(handleViewPad, UserLevelUser)).Methods("GET") } // All the existing stuff write.HandleFunc(draftEditPrefix+"/{action}/edit", handler.Web(handleViewPad, UserLevelUser)).Methods("GET") write.HandleFunc(draftEditPrefix+"/{action}/meta", handler.Web(handleViewMeta, UserLevelUser)).Methods("GET") // Collections if apper.App().cfg.App.SingleUser { RouteCollections(handler, write.PathPrefix("/").Subrouter()) } else { write.HandleFunc("/{prefix:[@~$!\\-+]}{collection}", handler.Web(handleViewCollection, UserLevelReader)) write.HandleFunc("/{collection}/", handler.Web(handleViewCollection, UserLevelReader)) RouteCollections(handler, write.PathPrefix("/{prefix:[@~$!\\-+]?}{collection}").Subrouter()) // Posts } write.HandleFunc(draftEditPrefix+"/{post}", handler.Web(handleViewPost, UserLevelOptional)) write.HandleFunc("/", handler.Web(handleViewHome, UserLevelOptional)) return r } // csrfProtectForm wraps a handler with CSRF protection but exempts JSON // requests, which authenticate via Authorization header rather than // session cookies and are therefore not CSRF-exploitable. func csrfProtectForm(key []byte, next http.Handler) http.Handler { protected := csrf.Protect(key, csrf.Path("/"))(next) return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if IsJSON(r) { next.ServeHTTP(w, r) return } protected.ServeHTTP(w, r) }) } func RouteCollections(handler *Handler, r *mux.Router) { r.HandleFunc("/logout", handler.Web(handleLogOutCollection, UserLevelOptional)) r.HandleFunc("/page/{page:[0-9]+}", handler.Web(handleViewCollection, UserLevelReader)) r.HandleFunc("/archive/", handler.Web(handleViewCollection, UserLevelReader)) r.HandleFunc("/{archive:archive}/page/{page:[0-9]+}", handler.Web(handleViewCollection, UserLevelReader)) r.HandleFunc("/lang:{lang:[a-z]{2}}", handler.Web(handleViewCollectionLang, UserLevelOptional)) r.HandleFunc("/lang:{lang:[a-z]{2}}/page/{page:[0-9]+}", handler.Web(handleViewCollectionLang, UserLevelOptional)) r.HandleFunc("/tag:{tag}", handler.Web(handleViewCollectionTag, UserLevelReader)) r.HandleFunc("/tag:{tag}/page/{page:[0-9]+}", handler.Web(handleViewCollectionTag, UserLevelReader)) r.HandleFunc("/tag:{tag}/feed/", handler.Web(ViewFeed, UserLevelReader)) r.HandleFunc("/sitemap.xml", handler.AllReader(handleViewSitemap)) r.HandleFunc("/feed/", handler.AllReader(ViewFeed)) r.HandleFunc("/email/confirm/{subscriber}", handler.All(handleConfirmEmailSubscription)).Methods("GET") r.HandleFunc("/email/unsubscribe/{subscriber}", handler.All(handleDeleteEmailSubscription)).Methods("GET") r.HandleFunc("/{slug}", handler.CollectionPostOrStatic) r.HandleFunc("/{slug}/edit", handler.Web(handleViewPad, UserLevelUser)) r.HandleFunc("/{slug}/edit/meta", handler.Web(handleViewMeta, UserLevelUser)) r.HandleFunc("/{slug}/", handler.Web(handleCollectionPostRedirect, UserLevelReader)).Methods("GET") } func RouteRead(handler *Handler, readPerm UserLevelFunc, r *mux.Router) { r.HandleFunc("/api/posts", handler.Web(viewLocalTimelineAPI, readPerm)) r.HandleFunc("/p/{page}", handler.Web(viewLocalTimeline, readPerm)) r.HandleFunc("/feed", handler.Web(viewLocalTimelineFeed, readPerm)) r.HandleFunc("/feed/", handler.Web(viewLocalTimelineFeed, readPerm)) r.HandleFunc("/t/{tag}", handler.Web(viewLocalTimeline, readPerm)) r.HandleFunc("/a/{post}", handler.Web(handlePostIDRedirect, readPerm)) r.HandleFunc("/{author}", handler.Web(viewLocalTimeline, readPerm)) r.HandleFunc("/", handler.Web(viewLocalTimeline, readPerm)) } diff --git a/static_fs_disk.go b/static_fs_disk.go new file mode 100644 index 0000000..680229c --- /dev/null +++ b/static_fs_disk.go @@ -0,0 +1,24 @@ +//go:build !embed + +/* + * Copyright © 2026 Musing Studio LLC. + * + * This file is part of WriteFreely. + * + * WriteFreely is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License, included + * in the LICENSE file in this source code package. + */ + +package writefreely + +import ( + "net/http" + "path/filepath" +) + +// staticFileSystem serves static assets straight off disk. This is the +// default build, so editing files under static/ is picked up without a rebuild. +func staticFileSystem(parentDir string) http.FileSystem { + return http.Dir(filepath.Join(parentDir, staticDir)) +} diff --git a/static_fs_embed.go b/static_fs_embed.go new file mode 100644 index 0000000..45a4be5 --- /dev/null +++ b/static_fs_embed.go @@ -0,0 +1,51 @@ +//go:build embed +// +build embed + +/* + * Copyright © 2026 Musing Studio LLC. + * + * This file is part of WriteFreely. + * + * WriteFreely is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License, included + * in the LICENSE file in this source code package. + */ + +package writefreely + +import ( + "embed" + "io/fs" + "net/http" + "path/filepath" +) + +//go:embed static +var embeddedStaticFiles embed.FS + +// staticFileSystem serves static assets embedded in the binary at build +// time, falling back to disk-based lookups so file-based overrides -- like +// static/local/custom.css, which is created after deployment and is never +// embedded -- still take precedence over the embedded copy. +func staticFileSystem(parentDir string) http.FileSystem { + sub, err := fs.Sub(embeddedStaticFiles, staticDir) + if err != nil { + panic(err) + } + return &overlayFileSystem{ + disk: http.Dir(filepath.Join(parentDir, staticDir)), + embedded: http.FS(sub), + } +} + +type overlayFileSystem struct { + disk http.FileSystem + embedded http.FileSystem +} + +func (o *overlayFileSystem) Open(name string) (http.File, error) { + if f, err := o.disk.Open(name); err == nil { + return f, nil + } + return o.embedded.Open(name) +} diff --git a/templates.go b/templates.go index 484bb99..37d512a 100644 --- a/templates.go +++ b/templates.go @@ -1,240 +1,276 @@ /* * Copyright © 2018-2021 Musing Studio LLC. * * This file is part of WriteFreely. * * WriteFreely is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License, included * in the LICENSE file in this source code package. */ package writefreely import ( "errors" "html/template" "io" + "io/fs" "net/http" - "os" - "path/filepath" + "path" "strings" "github.com/dustin/go-humanize" "github.com/writeas/web-core/l10n" "github.com/writeas/web-core/log" + "github.com/writefreely/writefreely/author" "github.com/writefreely/writefreely/config" ) var ( templates = map[string]*template.Template{} pages = map[string]*template.Template{} userPages = map[string]*template.Template{} funcMap = template.FuncMap{ "largeNumFmt": largeNumFmt, "pluralize": pluralize, "isRTL": isRTL, "isLTR": isLTR, "localstr": localStr, "localhtml": localHTML, "tolower": strings.ToLower, "title": strings.Title, "hasPrefix": strings.HasPrefix, "hasSuffix": strings.HasSuffix, "dict": dict, } ) const ( templatesDir = "templates" pagesDir = "pages" ) +// tmplFile identifies a template file within a specific filesystem, since +// the files that make up a single parsed template can be split across the +// independently-configurable templates/ and pages/ trees. +type tmplFile struct { + fsys fs.FS + name string +} + +// parseTemplateSet parses files into t in order, associating each with t the +// same way (html/template.Template).ParseFiles does -- it just reads from +// each file's own fs.FS instead of assuming a single filesystem. +func parseTemplateSet(t *template.Template, files ...tmplFile) (*template.Template, error) { + if len(files) == 0 { + return nil, errors.New("template: no files named in call") + } + for _, tf := range files { + b, err := fs.ReadFile(tf.fsys, tf.name) + if err != nil { + return nil, err + } + name := path.Base(tf.name) + var tmpl *template.Template + if name == t.Name() { + tmpl = t + } else { + tmpl = t.New(name) + } + _, err = tmpl.Parse(string(b)) + if err != nil { + return nil, err + } + } + return t, nil +} + func showUserPage(w http.ResponseWriter, name string, obj interface{}) { if obj == nil { log.Error("showUserPage: data is nil!") return } - if err := userPages[filepath.Join("user", name+".tmpl")].ExecuteTemplate(w, name, obj); err != nil { + if err := userPages[path.Join("user", name+".tmpl")].ExecuteTemplate(w, name, obj); err != nil { log.Error("Error parsing %s: %v", name, err) } } -func initTemplate(parentDir, name string) { +func initTemplate(tfs fs.FS, name string) { if debugging { - log.Info(" " + filepath.Join(parentDir, templatesDir, name+".tmpl")) + log.Info(" " + name + ".tmpl") } - files := []string{ - filepath.Join(parentDir, templatesDir, name+".tmpl"), - filepath.Join(parentDir, templatesDir, "include", "footer.tmpl"), - filepath.Join(parentDir, templatesDir, "base.tmpl"), - filepath.Join(parentDir, templatesDir, "user", "include", "silenced.tmpl"), + files := []tmplFile{ + {tfs, name + ".tmpl"}, + {tfs, path.Join("include", "footer.tmpl")}, + {tfs, "base.tmpl"}, + {tfs, path.Join("user", "include", "silenced.tmpl")}, } if name == "collection" || name == "collection-tags" || name == "collection-archive" || name == "chorus-collection" || name == "read" { // These pages list out collection posts, so we also parse templatesDir + "include/posts.tmpl" - files = append(files, filepath.Join(parentDir, templatesDir, "include", "posts.tmpl")) + files = append(files, tmplFile{tfs, path.Join("include", "posts.tmpl")}) } if name == "chorus-collection" || name == "chorus-collection-post" { - files = append(files, filepath.Join(parentDir, templatesDir, "user", "include", "header.tmpl")) + files = append(files, tmplFile{tfs, path.Join("user", "include", "header.tmpl")}) } if name == "collection" || name == "collection-tags" || name == "collection-archive" || name == "collection-post" || name == "post" || name == "chorus-collection" || name == "chorus-collection-post" { - files = append(files, filepath.Join(parentDir, templatesDir, "include", "post-render.tmpl")) + files = append(files, tmplFile{tfs, path.Join("include", "post-render.tmpl")}) } - templates[name] = template.Must(template.New("").Funcs(funcMap).ParseFiles(files...)) + templates[name] = template.Must(parseTemplateSet(template.New("").Funcs(funcMap), files...)) } -func initPage(parentDir, path, key string) { +func initPage(tfs, pfs fs.FS, pagePath, key string) { if debugging { - log.Info(" [%s] %s", key, path) + log.Info(" [%s] %s", key, pagePath) } - files := []string{ - path, - filepath.Join(parentDir, templatesDir, "include", "footer.tmpl"), - filepath.Join(parentDir, templatesDir, "base.tmpl"), - filepath.Join(parentDir, templatesDir, "user", "include", "silenced.tmpl"), + files := []tmplFile{ + {pfs, pagePath}, + {tfs, path.Join("include", "footer.tmpl")}, + {tfs, "base.tmpl"}, + {tfs, path.Join("user", "include", "silenced.tmpl")}, } if key == "login.tmpl" || key == "landing.tmpl" || key == "signup.tmpl" { - files = append(files, filepath.Join(parentDir, templatesDir, "include", "oauth.tmpl")) + files = append(files, tmplFile{tfs, path.Join("include", "oauth.tmpl")}) } - pages[key] = template.Must(template.New("").Funcs(funcMap).ParseFiles(files...)) + pages[key] = template.Must(parseTemplateSet(template.New("").Funcs(funcMap), files...)) } -func initUserPage(parentDir, path, key string) { +func initUserPage(tfs fs.FS, userPagePath, key string) { if debugging { - log.Info(" [%s] %s", key, path) + log.Info(" [%s] %s", key, userPagePath) } - userPages[key] = template.Must(template.New(key).Funcs(funcMap).ParseFiles( - path, - filepath.Join(parentDir, templatesDir, "user", "include", "header.tmpl"), - filepath.Join(parentDir, templatesDir, "user", "include", "footer.tmpl"), - filepath.Join(parentDir, templatesDir, "user", "include", "silenced.tmpl"), - filepath.Join(parentDir, templatesDir, "user", "include", "nav.tmpl"), + userPages[key] = template.Must(parseTemplateSet(template.New(key).Funcs(funcMap), + tmplFile{tfs, userPagePath}, + tmplFile{tfs, path.Join("user", "include", "header.tmpl")}, + tmplFile{tfs, path.Join("user", "include", "footer.tmpl")}, + tmplFile{tfs, path.Join("user", "include", "silenced.tmpl")}, + tmplFile{tfs, path.Join("user", "include", "nav.tmpl")}, )) } // InitTemplates loads all template files from the configured parent dir. func InitTemplates(cfg *config.Config) error { + tfs := templatesFileSystem(cfg.Server.TemplatesParentDir) + pfs := pagesFileSystem(cfg.Server.PagesParentDir) + // Let the author package resolve reserved usernames against the same + // (possibly embedded) pages filesystem instead of reading disk directly. + author.PagesFS = pfs + log.Info("Loading templates...") - tmplFiles, err := os.ReadDir(filepath.Join(cfg.Server.TemplatesParentDir, templatesDir)) + tmplFiles, err := fs.ReadDir(tfs, ".") if err != nil { return err } for _, f := range tmplFiles { if !f.IsDir() && !strings.HasPrefix(f.Name(), ".") { parts := strings.Split(f.Name(), ".") key := parts[0] - initTemplate(cfg.Server.TemplatesParentDir, key) + initTemplate(tfs, key) } } log.Info("Loading pages...") // Initialize all static pages that use the base template - err = filepath.Walk(filepath.Join(cfg.Server.PagesParentDir, pagesDir), func(path string, i os.FileInfo, err error) error { + err = fs.WalkDir(pfs, ".", func(p string, i fs.DirEntry, err error) error { if err != nil { return err } if !i.IsDir() && !strings.HasPrefix(i.Name(), ".") { key := i.Name() - initPage(cfg.Server.PagesParentDir, path, key) + initPage(tfs, pfs, p, key) } return nil }) if err != nil { return err } log.Info("Loading user pages...") // Initialize all user pages that use base templates - err = filepath.Walk(filepath.Join(cfg.Server.TemplatesParentDir, templatesDir, "user"), func(path string, f os.FileInfo, err error) error { + err = fs.WalkDir(tfs, "user", func(p string, f fs.DirEntry, err error) error { if err != nil { return err } if !f.IsDir() && !strings.HasPrefix(f.Name(), ".") { - corePath := path - if cfg.Server.TemplatesParentDir != "" { - corePath = corePath[len(cfg.Server.TemplatesParentDir)+1:] - } - parts := strings.Split(corePath, string(filepath.Separator)) - key := f.Name() - if len(parts) > 2 { - key = filepath.Join(parts[1], f.Name()) - } - initUserPage(cfg.Server.TemplatesParentDir, path, key) + // However deep the file is under templates/user/, it's keyed + // the same way showUserPage looks it up: "user/". + key := path.Join("user", f.Name()) + initUserPage(tfs, p, key) } return nil }) if err != nil { return err } return nil } // renderPage retrieves the given template and renders it to the given io.Writer. // If something goes wrong, the error is logged and returned. func renderPage(w io.Writer, tmpl string, data interface{}) error { err := pages[tmpl].ExecuteTemplate(w, "base", data) if err != nil { log.Error("%v", err) } return err } func largeNumFmt(n int64) string { return humanize.Comma(n) } func pluralize(singular, plural string, n int64) string { if n == 1 { return singular } return plural } func isRTL(d string) bool { return d == "rtl" } func isLTR(d string) bool { return d == "ltr" || d == "auto" } func localStr(term, lang string) string { s := l10n.Strings(lang)[term] if s == "" { s = l10n.Strings("")[term] } return s } func localHTML(term, lang string) template.HTML { s := l10n.Strings(lang)[term] if s == "" { s = l10n.Strings("")[term] } s = strings.Replace(s, "write.as", "writefreely", 1) return template.HTML(s) } // from: https://stackoverflow.com/a/18276968/1549194 func dict(values ...interface{}) (map[string]interface{}, error) { if len(values)%2 != 0 { return nil, errors.New("dict: invalid number of parameters") } dict := make(map[string]interface{}, len(values)/2) for i := 0; i < len(values); i += 2 { key, ok := values[i].(string) if !ok { return nil, errors.New("dict: keys must be strings") } dict[key] = values[i+1] } return dict, nil } diff --git a/templates_fs_disk.go b/templates_fs_disk.go new file mode 100644 index 0000000..bc4b696 --- /dev/null +++ b/templates_fs_disk.go @@ -0,0 +1,42 @@ +//go:build !embed + +/* + * Copyright © 2026 Musing Studio LLC. + * + * This file is part of WriteFreely. + * + * WriteFreely is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License, included + * in the LICENSE file in this source code package. + */ + +package writefreely + +import ( + "io/fs" + "os" +) + +// templatesFileSystem serves templates straight off disk. This is the +// default build, so editing files under templates/ is picked up without a +// rebuild. +func templatesFileSystem(parentDir string) fs.FS { + return diskContentFS(parentDir, templatesDir) +} + +// pagesFileSystem serves pages straight off disk. +func pagesFileSystem(parentDir string) fs.FS { + return diskContentFS(parentDir, pagesDir) +} + +func diskContentFS(parentDir, sub string) fs.FS { + root := parentDir + if root == "" { + root = "." + } + subFS, err := fs.Sub(os.DirFS(root), sub) + if err != nil { + panic(err) + } + return subFS +} diff --git a/templates_fs_embed.go b/templates_fs_embed.go new file mode 100644 index 0000000..dc691f6 --- /dev/null +++ b/templates_fs_embed.go @@ -0,0 +1,64 @@ +//go:build embed + +/* + * Copyright © 2026 Musing Studio LLC. + * + * This file is part of WriteFreely. + * + * WriteFreely is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License, included + * in the LICENSE file in this source code package. + */ + +package writefreely + +import ( + "embed" + "io/fs" + "os" +) + +//go:embed templates pages +var embeddedContentFiles embed.FS + +// templatesFileSystem serves templates embedded in the binary at build +// time, falling back to disk only for paths an admin has explicitly +// overridden -- e.g. by pointing templates_parent_dir at a custom theme, or +// dropping a replacement file next to the binary. +func templatesFileSystem(parentDir string) fs.FS { + return overlayContentFS(parentDir, templatesDir) +} + +// pagesFileSystem serves pages embedded in the binary, with the same +// disk-overrides-embedded behavior as templatesFileSystem. +func pagesFileSystem(parentDir string) fs.FS { + return overlayContentFS(parentDir, pagesDir) +} + +func overlayContentFS(parentDir, sub string) fs.FS { + root := parentDir + if root == "" { + root = "." + } + disk, err := fs.Sub(os.DirFS(root), sub) + if err != nil { + panic(err) + } + embedded, err := fs.Sub(embeddedContentFiles, sub) + if err != nil { + panic(err) + } + return &diskEmbedOverlayFS{disk: disk, embedded: embedded} +} + +type diskEmbedOverlayFS struct { + disk fs.FS + embedded fs.FS +} + +func (o *diskEmbedOverlayFS) Open(name string) (fs.File, error) { + if f, err := o.disk.Open(name); err == nil { + return f, nil + } + return o.embedded.Open(name) +}